Refactor: Replace heuristic SCC solver with LP-based solver#74
Conversation
LP pass-1 minimizes raw + SLACK_PENALTY × slack. The reported `totalRaw` sums only `rawCostPerFacility × x[r]` over recipes (slack penalty is excluded from the reported total). But pass-2's `lex_raw_cap` constraint set coefficients from each variable's `rawCost` field, including slack variables (which carry SLACK_PENALTY). The constraint LHS evaluated to `recipe_raw + SLACK_PENALTY × slack` while the RHS was `recipe_raw + tolerance` — mathematically infeasible whenever pass-1 had slack > 0. Pass-2 then fell back to pass-1's result, skipping power minimization entirely. On plans where raw-minimizing recipes also minimize power (currently most real data), this was invisible. On plans where they diverge, the solver returned an arbitrary (potentially high-power) choice among raw-degenerate recipes — silently violating the documented "lex raw → power" invariant. The bug was firing in 7+ separated-mapper test runs as "[LP_SOLVER] pass-2 infeasible after lex-cap; falling back to pass-1" warnings, but the affected scenarios' assertions did not depend on the recipe choices that emerged from the fallback, so tests passed despite the bug. Fix: skip slack variables when assigning lex_raw_cap coefficients. Slack is independently minimized via SLACK_PENALTY in pass-2's power objective, so it doesn't need an additional cap. Also adds a defensive `bounded === false` check on pass-2's result. The LP is currently bounded by structure (POWER_COST_FLOOR floor + lex_raw_cap ceiling), but this protects against future code changes that might break the bounding invariant. Regression test (`lex-cap excludes slack — power minimization works under forced slack`): two recipes with identical raw cost and asymmetric power, plus a disposal-slack constraint forcing slack=1. Asserts the low-power recipe is chosen and totalPower reflects it. The test fails without this fix (the fallback picks the first declared recipe). Verification: 100/100 tests pass. The seven "pass-2 infeasible" warnings in separated-mapper tests are eliminated. 30-scenario v0.6.2 / master / LP comparison shows no metric changes vs the pre-fix LP run — the fix is invisible on current data because no real plan triggers the recipe-power-degeneracy condition, but the documented invariant is now upheld. Reported by reviewer review of PR JamboChen#74.
- Replace Gaussian elimination + filterImpossibleDisposalRows + max(b/a) tie-breaking with javascript-lp-solver (~30 KB MIT). - Each SCC sub-problem becomes an LP: facility-count variables ≥ 0, per-item constraints (equal/min/disposal-slack), raw materials excluded from balance (counted only in cost objective). - Lex two-pass: minimize raw, then minimize power subject to raw cap from pass 1. - Recipe overrides become equality pins via LPInput.pinnedRecipes during feeder extension. - Delete src/lib/linear-solver.ts (109 LOC heuristic) and its tests. - Add 12 unit tests for the LP module.
- LP facility counts at non-integer rates (e.g. 1/6) leave 1e-13 residuals in injectDisposalRecipes' surplus calc, triggering disposal-recipe injection at facilityCount ≈ 0. - Mapper edge filters drop the near-zero edges, but the disposal sink node has no rate filter — rendered as a disconnected "0/min" node (e.g. Xircon Effluent on Xiranite Jade Gourd at 1, 2, 4, 5/min). - Add SURPLUS_EPSILON = 1e-6 tolerance in injectDisposalRecipes. - Add defensive node-rate filter (≤ 0.001) in both mappers. - Parameterized regression test over rates 1..6/min.
- When Tarjan places a forcedRawMaterial in scc.items via a byproduct cycle (Clean Water from LIQUID_PURIFIER_XIRANITE_POLY_1, Precipitation Acid from LIQUID_PURIFIER_COPPER_ENR_1), the LP excludes it from balance constraints and Phase 5 misses it (only iterates scc.externalInputs). - Raw item's consumption never reaches itemDemands → vanishes from plan output (e.g. SC Wuling Battery missing Clean Water). - New Phase 4.5 helper propagateRawMaterialDeficit, called from both solveSCCFlow and tryExtendSCCWithFeeders. Mirrors master's heuristic Phase 4 deficit logic, restricted to raw items. - Math.max(existing, deficit) semantics: surplus byproduct doesn't credit external demand (conservative; matches master). - Regression tests for SC Wuling Battery + Hetonite Solution.
- Pass-1 reports totalRaw summing only rawCostPerFacility × x[r] over recipes (slack penalty excluded). Pass-2's lex_raw_cap LHS used to include slack vars (carrying SLACK_PENALTY = 1e6), making the constraint infeasible whenever pass-1 had slack > 0. - Pass-2 then fell back to pass-1's result, skipping power minimization and silently violating the documented "lex raw → power" invariant. - Bug fired in 7+ separated-mapper test runs as "[LP_SOLVER] pass-2 infeasible after lex-cap" warnings, invisible on real data because affected scenarios' assertions didn't depend on the recipe choices that emerged from the fallback. - Skip slack vars from lex_raw_cap coefficient assignment. - Add defensive bounded === false check on pass-2 result. - Regression test: 2 power-asymmetric recipes with identical raw, forced slack=1, asserts low-power recipe is chosen. Reported by reviewer review of PR JamboChen#74.
fb44867 to
6874835
Compare
|
Force-pushed to rewrite commit bodies for concision (more scannable in No code changes from the previous push — the file diff vs master is byte-identical (12 files, +1482 / −694), and the four logical commits are preserved with the same subjects, just shorter dashed-list bodies. The PR description's commit-hash references have been updated to the new SHAs ( |
- Remove redundant block separators and trivial 'what' comments. - Trim verbose bug-context narrative blocks; full context lives in commit messages (fdda578, 19c301d, 57bbfd2, 6874835). - Keep API-contract JSDocs, magic-number rationales, phase markers, and defensive-guard explanations. - Net ~87 lines of comments removed across lp-solver.ts (-29), flow-solver.ts (-38), and lp-solver.test.ts (-19). No code changes.
|
I’m very interested in this PR overall. I had already seen some production-planning tools move toward LP-based solvers, for example FactorioLab, so the direction itself makes sense to me. My main hesitation about introducing LP was never about correctness, but more about the default user experience for early-game players. I worried that an optimizer could naturally converge toward solutions that rely on more advanced materials or facilities, which might not always match what a beginner expects or wants. That may just be my own assumption, but I still tend to prefer defaults that produce “good beginner-friendly” plans. My assumption is that users of this tool gradually learn and refine their production chains over time; otherwise they would probably just import blueprints directly. Also, from what I understand, the Simplex algorithm itself is one of the earliest and most mature LP algorithms, so I was actually surprised that the chosen library is relatively new by comparison, with its first release only around 2019. Could you explain why you chose BTW, I have also implemented a Simplex method myself before and even made it print intermediate steps for learning purposes. |
|
Thanks for digging in @JamboChen. And yes, LP-based solvers are an increasingly common pattern for these tools. Beginner-friendliness: the LP runs after recipe selection. If anything, the lex objective (raw, then power) is more beginner-friendly than the heuristic: the four PR #73 regressions in the comparison sweep were cases where master under-allocated the Refining Unit and silently produced an invalid plan; LP scales each producer to exactly what's needed. Across the 30 scenarios LP picks the same recipe families as master on every plan, so no tier creep on current data. A natural follow-up here: let users specify which materials and facilities they've unlocked, so the solver only considers what they have available. Library choice: honest answer, I picked Looking again, the closest JS alternative is
|
|
Thanks for the clarification and for investigating the alternatives in detail. Your explanation is very convincing to me.
This addresses most of my beginner-friendliness concern.
I’m not opposed to WASM in general, but given the scale of computation here, introducing WASM prematurely would probably create more downsides than benefits. A pure-JS dependency with zero runtime overhead and a permissive license seems like a very reasonable fit for this project.
For the current scale, the existing library already seems more than sufficient to me. Thanks again for the detailed investigation and explanation. @lsequeiraa |
… 3) (#80) * Refactor: Replace heuristic SCC solver with LP-based solver - Replace Gaussian elimination + filterImpossibleDisposalRows + max(b/a) tie-breaking with javascript-lp-solver (~30 KB MIT). - Each SCC sub-problem becomes an LP: facility-count variables ≥ 0, per-item constraints (equal/min/disposal-slack), raw materials excluded from balance (counted only in cost objective). - Lex two-pass: minimize raw, then minimize power subject to raw cap from pass 1. - Recipe overrides become equality pins via LPInput.pinnedRecipes during feeder extension. - Delete src/lib/linear-solver.ts (109 LOC heuristic) and its tests. - Add 12 unit tests for the LP module. * Fix: Suppress phantom disposal sink for floating-point surplus residuals - LP facility counts at non-integer rates (e.g. 1/6) leave 1e-13 residuals in injectDisposalRecipes' surplus calc, triggering disposal-recipe injection at facilityCount ≈ 0. - Mapper edge filters drop the near-zero edges, but the disposal sink node has no rate filter — rendered as a disconnected "0/min" node (e.g. Xircon Effluent on Xiranite Jade Gourd at 1, 2, 4, 5/min). - Add SURPLUS_EPSILON = 1e-6 tolerance in injectDisposalRecipes. - Add defensive node-rate filter (≤ 0.001) in both mappers. - Parameterized regression test over rates 1..6/min. * Fix: Propagate raw-material consumption when raw items are inside an SCC - When Tarjan places a forcedRawMaterial in scc.items via a byproduct cycle (Clean Water from LIQUID_PURIFIER_XIRANITE_POLY_1, Precipitation Acid from LIQUID_PURIFIER_COPPER_ENR_1), the LP excludes it from balance constraints and Phase 5 misses it (only iterates scc.externalInputs). - Raw item's consumption never reaches itemDemands → vanishes from plan output (e.g. SC Wuling Battery missing Clean Water). - New Phase 4.5 helper propagateRawMaterialDeficit, called from both solveSCCFlow and tryExtendSCCWithFeeders. Mirrors master's heuristic Phase 4 deficit logic, restricted to raw items. - Math.max(existing, deficit) semantics: surplus byproduct doesn't credit external demand (conservative; matches master). - Regression tests for SC Wuling Battery + Hetonite Solution. * Fix: Exclude slack variables from lex-pass-2 raw-cost cap - Pass-1 reports totalRaw summing only rawCostPerFacility × x[r] over recipes (slack penalty excluded). Pass-2's lex_raw_cap LHS used to include slack vars (carrying SLACK_PENALTY = 1e6), making the constraint infeasible whenever pass-1 had slack > 0. - Pass-2 then fell back to pass-1's result, skipping power minimization and silently violating the documented "lex raw → power" invariant. - Bug fired in 7+ separated-mapper test runs as "[LP_SOLVER] pass-2 infeasible after lex-cap" warnings, invisible on real data because affected scenarios' assertions didn't depend on the recipe choices that emerged from the fallback. - Skip slack vars from lex_raw_cap coefficient assignment. - Add defensive bounded === false check on pass-2 result. - Regression test: 2 power-asymmetric recipes with identical raw, forced slack=1, asserts low-power recipe is chosen. Reported by reviewer review of PR #74. * Refactor: Trim verbose comments to high-signal core - Remove redundant block separators and trivial 'what' comments. - Trim verbose bug-context narrative blocks; full context lives in commit messages (fdda578, 19c301d, 57bbfd2, 6874835). - Keep API-contract JSDocs, magic-number rationales, phase markers, and defensive-guard explanations. - Net ~87 lines of comments removed across lp-solver.ts (-29), flow-solver.ts (-38), and lp-solver.test.ts (-19). No code changes. * Add: Phase 3 multi-formula bin packing solver for Reactor / Expanded Crucible Adds a third optimization phase that automatically packs pool recipes into shared multi-formula buildings. Reactor Crucible (5 inner slots, 50W) and Expanded Crucible (8 inner slots, 100W) can each host multiple formulas simultaneously sharing inner-slot inventory and external port budget; the ILP picks the optimal packing under a lex (raw → buildings → power) objective. Algorithm - Phase 3 (`packCrucibleBins`) runs after `calculateFlows` produces per-recipe slot demands. - Bin enumeration: DFS over recipe subsets per multi-formula facility, pruning combinations that exceed inner-slot or port caps. - Equivalence classes: byte-identical recipe twins (e.g. _1/_2 pool variants) are pooled so the ILP can swap between Reactor and Expanded freely. - Lex two-pass MIP via javascript-lp-solver: minimise total buildings, then total power. Brute-force-style fallback if MIP misbehaves. - Recipe overrides respected: pinning forces a specific variant. - Allocations keyed by demand recipe id (Phase 2's pick) so downstream consumers reach the right plan node even when the ILP swaps variants. Type / data - New `FacilityCapabilities` (innerSlots, liquidIn/OutPorts, beltIn/OutPorts, optional maxFormulas) on `Facility`. Reactor and Expanded Crucible now carry capabilities; other facilities default to single-formula. - New `CrucibleBin` and `RecipeBinAllocation` shapes on `ProductionPlan`. - `ProductionGraphNode` (recipe variant) carries `binId` and `binSisterRecipeIds`; the recipe's `facility` is rewritten to the bin's facility when Phase 3 swaps variants. Mappers / UI - Merged and separated mappers thread `binId` and sister recipes onto flow nodes. Edges between co-located producer and consumer recipes carry `direction: 'internal'` and skip the transport label. - `flow-assertions` gains internal-edge invariants and bin-annotation completeness checks (dev-only). - `CustomProductionNode` shows a Layers badge on grouped nodes plus a tooltip section listing sister formulas. - `ProductionTable` per-row count column shows building count for grouped bins (with slot count below) and "(grouped)" for non-primary rows in the Power column. New plan-level totals footer reports total buildings, total power, and savings via grouping. - All seven locales updated. Tests (118 → 117 passing; spike removed after validation) - `multi-formula-packing.test.ts` — 11 unit tests covering Xircon triple, Reactor pair, port-cap rejection, override pinning, signature equivalence, deterministic output, singleton fallback. - `calculator.test.ts` — 4 new Phase 3 regression tests asserting `crucibleBins` / `recipeBinAllocations` correctness on real game data (Xircon at 30 and 60/min: 4 buildings, 400W). - `flow-integrity.test.ts` — every active recipe has a binId, internal edges only connect same-bin recipes. - `separated-mapper.test.ts` — Phase 3 bin annotations on facility nodes. * Fix: Phase 3 demand-vs-physical id mismatch and bin-aware totals The original Phase 3 implementation stored physical recipe ids on `bin.recipeIds` (e.g. `lx_2` when ILP swapped to Expanded), but every downstream consumer compared against demand recipe ids (Phase 2's pick, e.g. `lx_1`) — silently breaking sister filtering, primary-row power attribution, and internal-edge tagging on every Xircon-style plan. Root-cause fix: rewrite `bin.recipeIds` to mean DEMAND recipe ids. The bin's `facilityId` separately records the physical facility, which is all that's needed for power and building-count cost. With this, every consumer's plain-id comparison just works. Bug-for-bug fixes - `bin.recipeIds` now holds demand recipe ids; physical-recipe info lives on `bin.facilityId` (the only place it's needed). - Sister-recipe filter (calculator.ts, useProductionTable.ts) drops the row's own id correctly; previously included the recipe's own twin → "4 formulas" badge for a 3-formula bin. - Primary-row determination via `bin.recipeIds[0] === node.recipeId` works after Phase 3 swaps; previously every row in a swapped bin had `isBinPrimary = false` and the Power column showed "(grouped)" on every row. - `coLocatedPairs` in the separated mapper now matches Phase 2's recipe ids; internal edges are tagged correctly. - Internal direction now dominates backward when a recipe is BOTH in an SCC and co-located in a bin. Internal flow is always transport-free regardless of cycle membership; backward edges visualise the cycle but still imply transport. - Cycle-node mapping in calculator.ts uses physicalFacility (from bin lookup) consistent with the production-graph node above. - `applyEdgeStyling` preserves `direction === "internal"` and applies a distinct dashed-muted visual; previously internal edges lost their visual distinction at layout time. - Totals footer in ProductionTable now reads from a hook-computed `PlanTotals` derived from `plan.crucibleBins` directly. Previously derived from rows, which undercounted when a recipe was split across multiple bin shapes (asymmetric demand → MIP picks split). Quality / robustness - Defensive `r1.result` finite-number guard in solvePacking. - Dev-only warning when multiple `recipeOverrides` collide on the same equivalence class. - Removed dead `shapeBinId` map and `void recipeMap; void itemMap;` parameter swallows. - Localised the "internal" edge label via new `transport.internal` i18n key in all 7 locales. - Per-row spanning-info tooltip lists every bin a recipe occupies when split allocations occur. Tests - 4 new tests in multi-formula-packing.test.ts covering demand-id semantics, sister filter correctness, and totals aggregation. - 1 new test in calculator.test.ts asserting plan totals match `plan.crucibleBins` aggregate (split-allocation safe). - Strengthened flow-integrity.test.ts to positively assert at least one internal edge exists in the Xircon plan; previously the test passed vacuously when zero internal edges were emitted. - Strengthened separated-mapper.test.ts to verify the badge formula count matches the bin's recipe count (catches off-by-one). - Dropped the unnecessary disposal-skip in the recipe-bin-allocations test; disposals DO have allocations. 123 tests pass (was 118). * Refactor: clean up Phase 3 medium-severity review items #1 Remove empty no-op for-loop in useProductionTable totals computation; replace with a clarifying comment above the `plan.crucibleBins` iteration. #2 Use Facility[] from @/types in useProductionTable signature instead of a structural-subtype shorthand. Internal type inference now correctly preserves FacilityCapabilities through the hook. #3 Extract resolveBinInfo closure in buildProductionGraph; recipe-node and cycle-node bin lookups now share one implementation. Removes ~25 lines of duplicated logic. #4 Drop the dead `false` key in totalSlotsByCapability — only the multi-formula baseline is read. Replaced with a single `multiFormulaBaseline` accumulator. Also folded the second pass over `plan.crucibleBins` (for `multiFormulaActual`) into the main pass. #5 `bin.isGrouped` now reflects `demandIds.length >= 2` instead of `shape.recipeIds.length >= 2`. In the theoretical edge case where a multi-formula shape has only one demand class allocated, `isGrouped` correctly returns false to match the bin's user-visible recipe count. 123 tests still pass; no behavioural changes to optimal plans. * Add: bin-fused Recipe / Facility views for multi-formula buildings Multi-formula bins (Reactor / Expanded Crucible groups) now render as a single building card per bin in Recipe View and per physical building in Facility View. The card focuses on outputs (headline + byproducts); inputs come from incoming edges. Internal flows between co-located recipes are hidden from the graph and surfaced in the tooltip instead. For singleton bins (the common case), the cards render visually identical to the previous per-recipe layout — no regression. Architecture - New `bin-fused-mapper.ts` with two mappers: - `mapPlanToFlowBinFused` (Recipe View, default ON): emits one node per bin. Headline output picked by heuristic; bin's other external outputs become byproducts; internal items get no edges. - `mapPlanToFlowBinFusedSeparated` (Facility View, always ON): emits `ceil(bin.buildingCount)` nodes per bin — one per physical building, each running all bin formulas. Per-building rates = bin total / buildingCount. Per-building edges (one per upstream item per building) match Facility View per-instance philosophy. - `pickBinHeadlineOutput` heuristic in plan-helpers.ts: target -> tier -> solid -> alphabetical. Picks Xircon for the Xircon scenario (target + tier 3 + solid). UI - Recipe View toggle Buildings / Recipes near existing controls. Default = Buildings (bin-fused). Tooltip explains the trade-off. Persisted in URL hash as `bf=0` for off (default omitted). - Facility View has no toggle — always bin-fused (matches its job of showing physical layout). - CustomProductionNode enriched for grouped bins: lists all bin formulas, internal items, port utilization, and inner-slot count in the tooltip. Layers badge on the card itself. - Per-recipe (legacy) merged-mapper stays untouched as the fallback when the toggle is off — useful for chain debugging. Type extensions - ProductionNode.binExtraOutputs — bin's external outputs other than the headline; rendered as byproducts on the card. - ProductionNode.bin — full CrucibleBin reference for the tooltip's per-formula / internal-items / port-utilization sections. Tests - 12 new tests in bin-fusion-mapper.test.ts: - pickBinHeadlineOutput heuristic ordering. - Bin-fused merged mapper: one node per bin, headline correctness, bin metadata threading, internal flows produce no edges, no dangling edges in the Xircon plan. - Bin-fused separated mapper: N building-nodes per bin, per-building rate accuracy, no dangling edges. - All 135 tests pass (123 existing + 12 new). i18n - New app.binFusionLabelOn/Off/Tooltip keys. - New production.tree.formulasInBin/internalItems/innerSlotsUsed/ portUtilization keys. - All 7 languages updated. * Fix: Hide internally-balanced byproducts on bin-fused production cards CustomProductionNode rendered the headline recipe's secondary outputs (e.g. Sewage from POOL_XIRANITE_POLY_1) on top of binExtraOutputs, so a grouped {LX, XE, X} Xircon bin showed Sewage as a card byproduct even though X-produced sewage and XE-consumed sewage net to zero (correctly classified as internal in bin.internalItems). Extract the byproduct list computation into a pure function computeNodeByproducts(node, items) in plan-helpers.ts: for grouped bins (node.bin?.isGrouped) it relies solely on bin.externalOutputs (via binExtraOutputs); for singletons / per-recipe view it falls back to the recipe's outputs as before. Singletons and per-recipe view are unchanged because node.bin is undefined in those cases. Add 11 pure-function tests covering grouped/singleton/per-recipe branches and dedupe semantics. * Fix: Bin-fused view reports active production rates and avoids idle slots Phase 3's bin externalOutputs/internalItems used shape.netOutputs scaled by bin.buildingCount — full physical capacity. When a recipe's Phase 2 slot demand was less than the bin's buildingCount (e.g. Xircon at target=57: x_X=1.9 but the {LX,XE,X} bin had 3 X-slots), the bin card displayed 90 Xircon/min instead of the actual 57 demanded by the plan, and Sewage appeared as internal even though X under-produces it relative to XE consumption at the active rate. Two coordinated changes in multi-formula-packing.ts: 1. allocateSlotsToBins computes per-bin per-physical-recipe ACTIVE slot counts from allocEntries, then derives bin externalOutputs / externalInputs / internalItems from the active flows. Items netting to zero across active flows go to internalItems; positive nets to externalOutputs; negative to externalInputs. The result matches Phase 2 LP's mass-balance plan exactly. 2. solvePacking adds a lex pass 3 minimising Σ x_t × |recipes_in_shape_t| subject to building + power caps from passes 1-2. This is equivalent (up to a constant) to minimising total over-provisioning. For Xircon target=57, MIP now picks 2×{LX,XE,X} + 2×{LX,XE} (4 buildings, no idle slots) instead of 3×{LX,XE,X} + 1×{LX,XE} (4 buildings, one idle X-slot). Both packings tie on total buildings and total power; pass 3 breaks the tie toward the smaller-shape mix. Test updates: - multi-formula-packing.test.ts: revise the optimal-triple test to assert slot-coverage instead of a specific packing (pass 3 now picks the smaller-shape variant). Add fractional-demand active-rate test reproducing the user's target=57 scenario, plus a focused pass-3 tie-break test. - calculator.test.ts: rewrite the target=57 Xircon integrity test to assert the new active-rate behaviour (Xircon=57/min, Sewage shifted to externalInputs at 3/min). Strengthen Inv #2 to verify bin I/O classification matches active per-recipe slot net flows derived from plan.recipeBinAllocations. * Fix: Restore raw-material pickup node in bin-fused view for items with byproduct producers Raw materials produced as byproducts by some bin (e.g. Liquid Water produced by LIQUID_PURIFIER_XIRANITE_POLY_1 alongside Liquid Xiranite Poly) had their pickup node skipped in bin-fused (bf=1) view because the rawMaterialDemand loop did 'if (producersByItem.has(itemId)) continue' — treating any bin producer as supplying full demand. Result at Xircon target=56: 90/min water consumer demand had no visible source, while bf=0 correctly emitted the pickup node. bf=0 (merged-mapper) handles this via getItemProducers returning [] for all raw items regardless of byproduct producers, ensuring a raw pickup is always emitted. Mirror that in bin-fused-mapper by skipping raw items when registering producers in both merged and separated paths. The existing rawMaterialDemand loop then fires correctly for raw items, emitting a pickup node sized to total consumer demand. Producer bins still show the byproduct on their card via bin.externalOutputs (no data layer change). Add a focused regression test asserting (a) the Liquid Water pickup node exists in the Xircon plan, (b) no water-bearing edge originates from the Purifier bin, (c) edges flow from the pickup to consumer bins, (d) the Purifier bin's externalOutputs still includes water so the bin card displays it as a byproduct. * Fix: Production statistics aggregate multi-formula bins via shared helper useProductionStats previously counted facility requirements per recipe and ceiled each via getEffectiveFacilityCount, triple-counting shared multi-formula bins. At Xircon target=6 the {LX, XE, X} bin (1 Expanded hosting 3 recipes at fractional slot demand) reported as 'Expanded Crucible: 3' instead of 1, ignoring Phase 3 packing. Extract a shared aggregateBinTotals helper in plan-helpers.ts. Both useProductionStats and useProductionTable derive bin-level totals (totalBuildings, totalPower, perFacility, multiFormulaActual / BaselineBuildings) from this single source so they cannot drift. groupedSavings still computes from the always-ceiled baseline / actual fields — physical counterfactuals stay integer regardless of ceilMode. ceilMode now meaningfully differentiates the bin-level view: - ceilMode=true (physical): each bin contributes Math.max(1, Math.ceil(bin.buildingCount)). Power × ceiled buildings (a built building pays full power even at partial load). - ceilMode=false (theoretical): each bin contributes the MEAN of its recipes' active slot allocations (sum_alloc / recipe_count). Singletons collapse to bin.buildingCount (no change). Grouped bins surface the partial-load info that the integer building count would otherwise hide. By construction mean ≤ bin.buildingCount, so ceilMode=OFF never exceeds ceilMode=ON. The bin-fused-mapper's merged path mirrors the helper in ceilMode=OFF: a grouped {LX, XE, X} bin at Xircon target=57 with activities (LX=2, XE=2, X=1.9) shows '×1.97' instead of '×2' on the card, revealing the X recipe at 95% load. Singleton bins (incl. the Liquid Purifier) are unaffected. The Facility View (separated bin-fused path) is inherently a physical per-building view and is unchanged. Adds buildBinActivitySums as an exported helper so the mapper and the aggregator share the once-per-call walk of plan.recipeBinAllocations. * Fix: Facility View ignores bf=0 toggle from Recipe View The Recipe-View bin-fusion toggle is only rendered when visualizationMode === 'merged' (ProductionViewTabs.tsx), but the underlying binFusion boolean was still threaded into the separated branch of the mapper-selection ternary. When the user persisted bf=0 in the URL hash and switched from Recipe View to Facility View, the legacy per-recipe-instance separated mapper was used instead of the bin-fused per-building mapper, violating the documented invariant on ProductionDependencyTree's binFusion prop ('Has no effect on Facility View, which is always bin-fused.'). Collapse the separated branch so Facility View always picks mapPlanToFlowBinFusedSeparated regardless of the binFusion flag. The URL hash state itself is preserved across view switches: returning to Recipe View still restores the user's chosen toggle position. mapPlanToFlowSeparated remains exported for the test suites (separated-mapper.test.ts, flow-integrity.test.ts); only the production runtime path is collapsed. * Update: Bin-fusion toggle uses a stable label and hides when inert The toggle's label previously swapped between two value-nouns ('Buildings' / 'Recipes') depending on its state, causing a visible width shift on every flick in EN / ES / RU / KO locales (the CJK locales happened to be equal-width by coincidence of the chosen wording). Adopt the convention used by the sibling switches (ceilMode, twoEndAlignment): a single stable label naming the concept being toggled. New EN label 'Group recipes'; the switch position alone conveys state. Tooltip also simplified to a one-sentence outcome description: 'Combine recipes that share a building into one card.' The 'for chain debugging' justification for the OFF state is dropped — if a user is poking the toggle they will see what happens. Also hide the toggle entirely when the plan contains no grouped bins. The signal is plan.crucibleBins.some((bin) => bin.isGrouped) — true exactly when at least one bin packs ≥2 demand recipes, which is the only case where ON / OFF produce different visualisations. The binFusion state is preserved in URL hash and React state across visibility transitions; if the user later targets a multi-formula chain, the toggle reappears in their previously-set position. All 7 locales updated with new label + tooltip strings. * Refactor: Adopt upstream factory-buildings schema for Facility Aligns the calc-side Facility type with the game-data dump emitted by the upstream FactoryBuildingTable extractor. Drops the calc-only FacilityCapabilities aggregate in favor of the dump's native shape (channelsIn / channelsOut + cacheSlots), letting the solver read the same data the game does without an intermediate translation layer. Phase A — schema refactor: - Facility now carries numId, tier, category, powerConsumption, channelsIn, channelsOut, optional cacheSlots, domains, cap. - Multi-formula capability is signalled by cacheSlots != null (replacing capabilities != null). - Distinct-item port caps are derived from channel counts at the call site: liquidInPorts = channelsIn.pipe.length liquidOutPorts = channelsOut.pipe.length beltOutPorts = channelsOut.belt.length Belt-input variety remains uncapped wrt bin packing; the unused beltInPorts check is removed from multi-formula-packing.ts. - Channel topology (per-pipe / per-belt port counts), category, numId, domains, and placement caps are preserved as advisory data for future consumers (per-channel routing visualisation, placement-aware planning, categorical filters). Today's solver does not read them. Phase B — FacilityId rename (item_port_* prefix dropped): - FacilityId enum re-keyed to bare game IDs (component_mc_1, dismantler_1, …). Four meaningful renames (cmpt → component, filling_pd → filling_powder, seedcol → seedcollector, tools_asm → tools_assebling) plus 12 simple prefix strips, sourced from FactoryBuildingItemTable.json's buildingId fields. - Three transport buildings (loader_1, pump_1, pump_2) added to the enum + facilities catalog as inert data, ready for future transport-aware planning; no recipe references them today. - All recipe.facilityId references rewritten (~260 occurrences). - Image files renamed via git mv (history preserved). - Facility i18n keys renamed in all 7 locales; translations unchanged. Test fixtures across multi-formula-packing.test.ts, plan-helpers.test.ts, calculator.test.ts, capacity-pool.test.ts, lp-solver.test.ts, and fixtures/test-data.ts are restructured inline to the new schema — no semantic-translation helper layer. The facility test-helper in multi-formula-packing.test.ts becomes a thin Partial<Facility> defaults wrapper so authors write the dump-shape fields they care about explicitly. CustomProductionNode's port-utilisation tooltip now reads channels directly. plan-helpers' multi-formula bin filter switches from facility.capabilities to facility.cacheSlots != null. * Refactor: Clean up bin-fused-mapper and harden resolveBinInfo fallback - Replace hardcoded transport-capacity constants (120/30) in bin-fused-mapper with the shared getTransportCapacity() helper so pipe/belt capacity stays consistent with the rest of the calculator. - Remove unused ProductionGraphNode type import and the obsolete `void` workaround block at the end of bin-fused-mapper; current lint config no longer requires it. - Gate resolveBinInfo's documented "rare" fallback paths behind an import.meta.env.DEV warning so future Phase 3 coverage regressions surface during development. * Refactor: Rename Facility channels to buffers to match engine terminology The upstream factory-buildings dump renamed Channel/Channels and the channelsIn/channelsOut fields to Buffer/Buffers and buffersIn/buffersOut, aligning the schema with the game engine's own naming. The engine uses 'buffer' to describe the binding/grouping layer that ties one or more physical ports to one cache — see *BufferBinding[] arrays in FactoryMachineCraftGroupTable.json and buildingBufferStackLimit in FactoryItemTable.json. Mechanical rename across consumers: - src/types/core.ts: Channel → Buffer, Channels → Buffers, fields and exports updated; doc comments rewritten to use 'buffer' prose. - src/data/facilities.ts: all 19 entries. - src/lib/multi-formula-packing.ts: solver reads facility.buffersIn.pipe.length etc.; header comments updated. - src/components/nodes/CustomProductionNode.tsx: port-utilisation tooltip reads buffer counts. - All test fixtures (multi-formula-packing, plan-helpers, lp-solver, capacity-pool, fixtures/test-data) updated inline. No behavior change. Cap derivation rules unchanged: liquidInPorts = buffersIn.pipe.length liquidOutPorts = buffersOut.pipe.length beltOutPorts = buffersOut.belt.length * Fix: Bin-fused mappers skip target-sink emission for zero-rate targets A target with rate=0 (reachable via a URL hash like #t=item_steel:0, which the parseHash parser accepts via rate>=0) caused both bin-fused mappers to emit an isolated target-sink node. The consumer-registration loop correctly skips zero-rate targets via the existing `if (userTargetRate <= 0.001) return;` guard, but the sink-emission loops were missing the matching guard — so the emitted sink had no incoming edges and tripped assertFlowIntegrity's "isolated node" warning in dev mode for legitimate user input. Mirror the same guard in both sink-emission loops so registration and emission stay in lock-step. Add regression tests in bin-fusion-mapper.test.ts for both mapper variants. * Refactor: Drop Crucible prefix from algorithm-agnostic bin types Multi-formula bin packing is a building-family-agnostic concept; future multi-formula facilities should slot in without semantic friction. Identifier renames: CrucibleBin → Bin, crucibleBins → bins, packCrucibleBins → packBins, [CRUCIBLE_PACKING] → [BIN_PACKING], tree.crucibleGroup → tree.multiFormulaGroup. User-facing label "Crucible Group" → "Multi-Formula Building" (and locale equivalents); savingsExplain swaps building-specific nouns for agnostic "buildings". Abstract algorithm prose generalized; test scenarios + historical bug refs kept specific. In-game building names in facility.json untouched. * Fix: Multi-formula packer honors per-pin overrides via split constraints Restructure solvePacking and allocateSlotsToBins so each pinned demand recipe gets its own restricted constraint, combined with the existing class-wide total. When two items in the same equivalence class are pinned to different recipe variants (e.g. tier-1 vs tier-2 facility), both pins are honored simultaneously instead of the old "last wins" heuristic that silently dropped one. Pinned demands allocate first so they claim restricted shapes before unpinned demand can substitute. Surface infeasible pins (override restricting to a recipe with no valid bin shape) via a new ProductionDependencyGraph.warnings field plumbed through PackingResult -> calculator -> useProductionPlan's warnings banner. Users get a diagnostic instead of silent fallback. Tests cover the conflict, single-pin + unpinned coexistence, and infeasible-pin fallback cases. * Update: SavedPlan persists binFusion toggle Add optional binFusion field to the SavedPlan JSON schema so file save/load round-trips preserve the user's view toggle. Legacy saves that predate bin-fusion omit the field; the loader defaults to true to match parseHash and the in-app default. Resolves the inconsistency where the URL hash kept bf=0 but the JSON save/load path silently snapped the toggle back to default. * Fix: Bin-fused mappers fold singleton-terminal targets into target sink Restore bf=0 parity for simple A -> B chains by detecting singleton- terminal bins (single recipe, single external output, sole producer of a target with no other consumers) and folding their card into the target sink's embedded productionInfo chip — matching the existing isRecipeTerminal collapse in merged-mapper.ts. Bake the bin -> sink redirect into producersByItem / consumersByItem construction rather than remapping post-hoc: skipped bins are excluded from producer entries, and their input items have consumer entries keyed to the target sink id so upstream edges land on the sink directly. This is the merged-mapper terminal-recipe edge rerouting (merged-mapper.ts:204-211) but at map-build time. Two regressions hit by the post-hoc approach (isolated raw_item_liquid_water + target-sink-item_xiranite_powder on Xiranite Powder, missing star ribbon on Xircon Poly @ 60/min Facility View) trace to the "phantom entries that get retroactively dropped" pattern. For the separated mapper, the skip gate additionally requires ceil(buildingCount) === 1 so multi-building targets keep their per-building cards. The per-building emission options now also set isDirectTarget / directTargetRate based on the headline-target match, restoring the amber Star ribbon for terminal multi-facility targets (previously hardcoded false). Elevate assertFlowIntegrity to throw on any violation in test mode (import.meta.env?.MODE === "test") so dangling edges, isolated nodes, and cross-bin internal flows become hard test failures instead of silently-warning console noise. Production browser behavior unchanged (still warns). Tests cover: skip+embed parity for iron-nugget (Recipe + Facility), Xiranite Powder regression (multi-input terminal), multi-building counter-case with no embed, star ribbon for Xircon Poly grouped and iron-nugget singleton multi-building, plus no-isolated-node guards in every new test. * Fix: Bin-fused mapper cycle layout, target allocation priority, and ELK priority clamping Three related correctness fixes for bin-fused rendering and layout: 1. Backward edge tagging for cycle layout (mapPlanToFlowBinFusedSeparated): build cyclePairs from plan.detectedCycles and tag bin-to-bin edges between cycle-partner recipes with direction="backward". layout.ts reads this to set elk.layered.priority.direction, deprioritizing cycle edges so ELK reverses them during layered cycle breaking instead of arbitrary forward edges. Restores parity with mapPlanToFlowSeparated:64-76,287-296 — without the tag, multi-bin cycles like the moss seed loop (planter <-> seedcollector) laid out inconsistently between bf=0 and bf=1 in Facility View. 2. Target priority over disposal in greedy allocation (both bin-fused mappers): swap consumer registration order so target sinks are added BEFORE disposal-bin consumers in consumersByItem. The greedy allocator iterates in insertion order; previously disposal got first pick at producer output, leaving target sinks epsilon under-allocated in floating-point edge cases. Matches the target-pass-then-disposal-pass structure of mapPlanToFlowSeparated. 3. ELK priority.direction clamping (layout.ts): the option has lower bound 0 per ELK docs, so the previous "-10" value for backward edges was silently clamped to 0. Now uses "0" explicitly with a comment documenting the option semantics. Same effective behaviour on master but expresses the intent correctly. Tests: - New: moss seed cycle edges carry direction=backward (#3). - New: target sink incoming edges sum to userTargetRate in both views (#6). * Refactor: Remove dead-code legacy separated mapper mapPlanToFlowSeparated has been the legacy per-recipe Facility View mapper since the bin-fused mapper was introduced. Commit 7a9eb7b ("Facility View ignores bf=0 toggle from Recipe View") removed its last production call site — `ProductionDependencyTree.tsx` now always uses mapPlanToFlowBinFusedSeparated for separated mode. The legacy mapper has been kept alive only by its own test file and three test cases in flow-integrity. Migrate the surviving regression coverage and delete: - src/components/mappers/separated-mapper.ts (~900 LOC). - src/tests/lib/separated-mapper.test.ts (~350 LOC). - src/lib/node-keys.ts: createPickupPointId (only used by the deleted mapper). - src/lib/utils.ts: getOutputAmount and calcByproductRate (likewise). - src/lib/utils.ts: remove unused Recipe type import. Migrated coverage: - flow-integrity.test.ts: replace the three separated integrity cases (copper_nugget, iron_nugget, xiranite_poly) with bin-fused Recipe + Facility variants. Drop the "internal-flow edges only between same-bin recipes" test entirely — bin-fused has no cross-bin internal edges (intra-bin flows are hidden as internalItems), so the invariant doesn't apply. - bin-fusion-mapper.test.ts: add the Battery SCC scenario (from byproductSCCRecipes synthetic data) to exercise a different graph topology than Xircon, and the Phase 3 sister-count off-by-one regression test (real data, grouped Xircon bins). Doc comments in bin-fused-mapper.ts continue to reference the now- deleted file as historical context — those references document the design rationale and are intentionally retained. Net change: -1280 LOC. * Refactor: Audit and trim PR comments Sweep of comments added or rewritten throughout the bin-fused mapper work. Net -21 LOC across 5 files; zero behaviour change. Fixes (Tier 1) — broken cross-file references after the legacy separated-mapper deletion: - bin-fused-mapper.ts: 8 comment blocks referencing the now-deleted mapPlanToFlowSeparated / separated-mapper.ts. Replaced with behavioural descriptions or trimmed where the comment was self- contained. - bin-fusion-mapper.test.ts: 3 broken refs in test rationale comments rewritten; 2 "Migrated from now-deleted separated-mapper.test.ts" historical markers removed (the rationale is what matters). - capacity-pool.ts: docstring referenced allocateFromPool, a function from the deleted mapper. Rephrased to "activated by the caller via markProcessed". Consolidations (Tier 2): - bin-fused-mapper.ts: the singleton-terminal detection block in the Facility View function shrank from a duplicate of the merged-view explanation to a short cross-reference plus the extra N === 1 gate. - bin-fused-mapper.ts: replaced opaque "Option A vs Option B from the design discussion" prose with the concrete technical reasoning (alternative left phantom state in maps; isolated-node bugs traced back to that pattern). - flow-assertions.ts: "Xiranite Powder isolated-nodes" name-checked twice; kept as motivating example in one location, generalised the other. Fluff removal (Tier 3): - bin-fused-mapper.ts: dropped pure-label "// Bin classification." comment; collapsed 4 redundant lines in the edge-emit defensive filter into one. Line-number stripping (Tier 4): - All file:line citations replaced with file-only references. Line numbers drift; the symbol/concept references are stable. Touched bin-fused-mapper.ts (4), bin-fusion-mapper.test.ts (2), and plan-helpers.test.ts (1, pre-existing stale ref). * Refactor: Remove dead-code CapacityPoolManager and tighten leaky exports Tier A — dead-from-app code removed after the legacy separated mapper deletion (commit 02051da): - src/components/flow/capacity-pool.ts (~220 LOC). CapacityPoolManager was only used by mapPlanToFlowSeparated; the bin-fused mapper has its own per-building rate computation and doesn't need it. - src/tests/lib/capacity-pool.test.ts (~220 LOC). 10 tests for the deleted implementation. - src/types/flow.ts: drop FacilityInstance, CapacityPoolEntry, and AllocationResult types — orphaned with the implementation. Tier B — tighten 5 leaky `export` keywords + delete 1 unused type surfaced by knip: - src/lib/graph-builder.ts: drop `isDismantleRecipe` from the re-export block (only used inside the file); delete `export { getOrThrow }` entirely (likewise internal). - src/lib/plan-helpers.ts: drop `export` from `getRecipeOutputItemIds` (only used by `getRecipeOutputItemId` within the same file). - src/lib/utils.ts: drop `export` from TRANSPORT_BELT_CAPACITY and TRANSPORT_PIPE_CAPACITY (consumed only by `getTransportCapacity` inside the same file). - src/components/flow/flow-utils.ts: delete `AggregatedProductionNodeData` type entirely — defined but never imported. Net: -501 LOC. Tests drop from 211 to 201; both implementations are covered exclusively elsewhere now. Knip drops 5 unused exports and the 1 unused type from its surface area; the remaining unused exports are shadcn UI subcomponents intentionally kept for future use. * Update: CLAUDE.md reflects bin-fused architecture and current invariants Refresh project memory for the Phase 3 multi-formula packing work that landed in this branch. Trim documentary content (which Claude can infer from `ls` / `grep`) so each retained line carries a rule or non-obvious decision. Restructures: - "Repository Map" becomes "Where critical logic lives" — 7 non-obvious files explicitly called out; the exhaustive enumeration is gone. - New "Bin-fused architecture" section: mapper-selection table plus the singleton-bin-for-uniformity rule. - "Algorithm invariants" drops two stale entries (merged+separated mapper agreement, backward-byproduct allocateByproduct) and adds six new ones from the bin-fused / packer work (demand-id rewrite, active-rate I/O, aggregateBinTotals as single source of truth, per-pin + class-total constraints, singleton-terminal detection ordering, target-before-disposal allocator order, assertFlowIntegrity test-mode throw). - "Anti-patterns" updates the separated-mapper reference to bin-fused-mapper and adds five new ones (aggregateBinTotals bypass, late singleton-terminal detection, disposal-before-target consumer registration, direct solver.Solve calls, negative ELK priority). - "Terminology" drops inferable terms (SCC, productionRate, actualOutputRate) and adds Bin / singleton vs grouped / internal items. - "Internationalization" trimmed to the recipe-namespace generation rule; file-path documentation removed. Stale references purged: no remaining mentions of `separated-mapper`, `CapacityPoolManager`, `capacity-pool`, `actualOutputRate`, or `calcByproductRate` — all of which were deleted on this branch. Final size: 133 lines (down from 138). Within the under-200 target recommended by Anthropic's CLAUDE.md guidance; rule density up. * Update: Bin-fused card with internal-items row and grouping chip Redesign the production-node card to make multi-formula bins self-explanatory at a glance and drop the floating purple Layers badge that overlapped the item icon. Zone 1 unchanged: primary output + byproducts in the bin's externalOutputs. NEW Zone 1.5 (grouped bins only): a muted, dashed-bordered row showing internal items as small icons. Driven by `node.bin?.internalItems` — only rendered when the variant has items balanced internally (e.g., Liquid Xiranite in a {LX, XE, X} bin). Items are de-emphasised (60% opacity, hover for name) to clearly distinguish from Zone 1's exported items. Zone 2 chip becomes two lines for grouped bins: - Line 1: facility icon + name + `×N` count (identical to singleton chip — full chip width, no truncation). - Line 2: `Boxes` icon + `N formulas` text. Muted styling. The floating `-top-1 -left-1` purple `Layers` badge is gone — all grouping signal now lives inline in the chip and Zone 1.5. Singleton chips are byte-identical to their pre-change rendering. New i18n key `tree.internal` (English fallback "Internal"). * Fix: Bin packer enforces strict-equality demand with sub-visible variant filter Reworks Phase 3 (multi-formula packing) from a flexible-LP that permitted over-production into a shape-variant enumeration that honours Phase 2 demand exactly, and filters out LP-floating-point residue before it surfaces as orphan nodes downstream. # Path H: shape-variant enumeration (multi-formula-packing.ts) For each multi-formula class, enumerate every combination of recipes a building could host (the 'shape variants'), and for each variant pre-compute its canonical rate-direction vector by Gaussian-eliminating the null space of its rate ratios. The LP then chooses how many buildings of each variant and a single scale `u_v` per variant, instead of one continuous slot count per (recipe, building) pair. This makes port-cap safety a property of the variant set itself (every variant in the set is feasible by construction) rather than an after-the-fact constraint the LP has to satisfy via indicator variables or big-M. Hybrid solver path: integer MIP when `variants.length < 30 && maxClassDemand < 10` (typical 1-3 target plans, optimal building count); continuous-LP-and-round-up otherwise (high-variant or high-demand plans like 12-target or 156-slot, avoids 90s B&B). # Strict equality (fixes Xircon 6/min -> 9.60 over-production bug) Demand constraints are now `{ equal: D }` instead of `{ min: D }`. The previous min-style let the LP pick a V1 variant with `u=0.32` that forced X to over-produce. Strict equality + variant enumeration spans the conic hull of singletons so feasibility is preserved. Test-mode `throw` on equality-LP infeasibility (catches future data drift); production falls back to `emitSingletonBins`. # Sub-visible variant filter (fixes orphan-node bug) Variants emitting rates below `MIN_VISIBLE_RATE_PER_MIN` (0.001/min) are skipped at packer-emission time. Without this, LP FP residue (u ~1e-6) in vestigial 2-recipe variants emitted real `Bin`s with ~3e-5/min rates that the mapper then dropped, leaving the bin with no incident edges and triggering `assertFlowIntegrity`. Filter uses `max(input_amount, output_amount)` per item — covers recipes like X consuming 2 LXP per 1 Xircon where input bound is tighter than output bound. Repro: 3-target plan {Hetonite Part, SC Wuling Battery, Yazhen Syringe} @ 6/min each. # Shared visibility threshold (flow-thresholds.ts) NEW module exporting `MIN_VISIBLE_RATE_PER_MIN = 0.001`. Replaces 21 hardcoded `0.001` literals in `bin-fused-mapper.ts`. Used both at packer emission and at mapper edge construction so the two views agree by construction. # FP-tolerance pickup count (bin-fused-mapper.ts:908, :1062) `Math.ceil(totalDemand / transportCap)` rounded 480.0000001 / 120 to 5 instead of 4, creating phantom 5th pickup with zero rate. Now: `Math.ceil((totalDemand - MIN_VISIBLE_RATE_PER_MIN) / transportCap)`. Same threshold applied at both sites. # Type changes (production.ts) `Bin.variantId: string` — required field identifying the shape variant the bin instantiates. Threads through plan-helpers and mapper assertions. # Invariant strengthening (multi-formula-packing.ts) `assertBinPortCaps` runs in test mode (throws) and dev (warns) — catches packer regressions that produce bins exceeding facility `maxInputs`/`maxOutputs` budget. # Test infra - vite.config.ts: import from `vitest/config` (was `vite`), `testTimeout: 30000` for slower MIP plans in CI. - multi-formula-packing.test.ts: +12 tests covering port-cap invariants, equality infeasibility throw, variant enumeration, sub-visible filter. - bin-fusion-mapper.test.ts: 3-target orphan-bin regression (both Recipe and Facility views); `variantId` added to 4 `Bin` fixtures. - calculator.test.ts: Xircon target=N tests aggregate rate across bins (was per-recipe). - plan-helpers.test.ts: `variantId` added to 5 `Bin` fixtures; `mean strictly below buildingCount` relaxed to accommodate per-variant scaling. # Verification - 205/205 tests pass in 5.8s. - 5/5 determinism runs identical. - 25/25 sweep combinations (5 target items x 5 rates). - 38ms for 12-target plan. - 3/3 disposal-injection scenarios. * Refactor: Drop dead code and redundant brand casts in bin-fused pipeline Cleanup commit; no runtime behaviour change. Each item identified by the post-PR review of feat/expanded-crucible-packing. - Remove 6 redundant `as ItemId` casts in bin-fused-mapper.ts. The `ProductionGraphNode` type already brands `itemId: ItemId` inside the `type === "item"` variant, so casts after the early-return guard are no-ops. CLAUDE.md anti-pattern. - Hard-delete `effectiveTotals` fallback in ProductionTable.tsx. The fallback recomputed plan totals from row data using `Math.ceil` unconditionally — bypassed `aggregateBinTotals` and ignored `ceilMode`, contradicting the single-source-of-truth rule. Dead in practice (the sole caller, ProductionViewTabs, always passes `tableData.totals`). Footer now reads `totals.*` directly; the prop becomes required. - Drop unused `facilities` prop from ProductionTable. Declared in the prop type but never referenced inside the component body. Caller (ProductionViewTabs) drops the corresponding argument. - Migrate 4 bare `0.001` literals in merged-mapper.ts (edge skip, target-allocation gate, disposal-rate gate, disposal-edge skip) and 2 in plan-helpers.ts (`computeGreedyAllocation` demand and available thresholds) to `MIN_VISIBLE_RATE_PER_MIN` from flow-thresholds.ts. Completes the threshold-extraction work started for the bin-fused pipeline; merged-mapper and the greedy allocator now share the same visible-rate cutoff as the packer. - Fix off-by-one `colSpan={9}` -> `colSpan={8}` on the empty-state row in ProductionTable. The table renders 8 `TableHead`s. - Move `CustomBezierEdge` import from line 198 to the top of ProductionDependencyTree.tsx alongside the other 13 imports. - Fix in-place mutation in `handleTargetChange`. Previously the setter shallow-copied the array but kept `prev[index]` reference identity intact while mutating its `rate` field. Now uses `prev.map` to produce a fresh object at the target index too — safe for any consumer that memoises off object identity. - Add comment on `tools_assebling_mc_1` in constants.ts noting the typo is preserved verbatim from the upstream `FactoryBuildingTable` dump (the `bun run extract:recipes` pipeline matches upstream IDs, image asset, recipe references — renaming locally would break the chain). - Add comment on `displayPlan` clarifying that `plan.bins` and `plan.recipeBinAllocations` are intentionally not filtered. Phase 3 only emits bins for recipes with positive slot demand, so the surviving-bin set is always consistent with the surviving-node set after zero-rate filtering. Pre-ship: 205/205 tests pass; lint clean of new warnings (only pre-existing shadcn react-refresh warnings); build succeeds; knip surfaces no new unused exports. * Add: Solver timeout guard and pin packer invariants with tighter tests Defensive infrastructure plus contract pinning. No runtime behaviour change on current workloads. # Solver timeout `solver.Solve` on `javascript-lp-solver` v1.0.3 has a documented class of bugs around degenerate LP cycling (#112) and tight-equality MIP B&B that can hang indefinitely on pathological problems. The existing `USE_INTEGER_LP` variant-count threshold protects against the typical cliff, but it's a soft guard — a 25-variant plan with maxClassDemand=9 stays on the integer path even if the specific problem turns out to be pathological. Add `options: { timeout: 30000 }` to all three lex-pass models in `solvePacking`. The solver returns the best integer solution found so far on timeout (or `feasible: false` if none); our existing try/catch + lex fall-back chain catches the latter and degrades to `emitSingletonBins`. 30 s matches vitest's `testTimeout` in `vite.config.ts`. # Strict-equality demand: per-recipe upper-bound assertions Existing tests asserted `allocated ≥ phase2 − 1e-6` only. Under strict equality (`{ equal: demand }`) the allocation should EQUAL demand, not just exceed it. A future regression that re-introduced over-allocation (the original Xircon target=6/min bug — Phase 3 emitted 9.6/min from an under-constrained LP) wouldn't be caught. - `multi-formula-packing.test.ts`: - "optimal triple: 4 buildings on Expanded" now asserts `toBeCloseTo(demand, 2)` per recipe (±0.005 slot tolerance). - "fractional demand at target=57" same treatment. - `calculator.test.ts`: - Renamed parameterised test from "Phase 3 allocation ≥ Phase 2" to "Phase 3 allocation matches Phase 2 (strict equality)". - Now reads `alloc.perBin.slots` (the true per-bin allocation) instead of `bin.buildingCount` (which only bounds it from above). Asserts `phase2 − 0.005 ≤ allocated ≤ phase2 + 0.005`. - Keeps the building-count lower-bound check as a separate physical invariant. The 0.005-slot tolerance is generous against the documented sub-visible-variant rate drift (< 0.005 items/min cumulative per plan, see `docs/path-h-design.md` Trade-off 1) while still catching over-provisioning down to ~0.5%. # `bin.recipeIds` ascending-sort contract pinned `useProductionTable.ts:263` relies on `bin.recipeIds[0]` being the alphabetically-first recipe id (the "primary row owns the power" heuristic — non-primary rows in grouped bins show "(grouped)" and zero power). The packer guarantees this at `multi-formula-packing.ts:1306` via `.sort()`, but no test previously pinned the contract. New test in `deterministic-output` describe creates a grouped bin where the demand-recipe IDs are intentionally not in alphabetical order in the input (`z_recipe`, `a_recipe`). Asserts every emitted bin's `recipeIds` is strictly ascending, and the grouped bin specifically places `a_recipe` first — proving the contract is active, not vacuously satisfied. Pre-ship: 206/206 tests pass; lint clean of new warnings; build succeeds. * Add: BinId brand for type-safe bin identifier propagation Type-system-only change; no runtime behaviour change. Closes the brand-discipline gap noted by the post-PR review. # Motivation `ItemId`, `RecipeId`, and `FacilityId` are nominally-typed via literal-string unions of the closed game-data enum (`src/types/constants.ts`). Bin IDs are the only first-class plan identifier without nominal typing — they're dynamically constructed by `makeBinId` and previously typed as plain `string`. Without a brand: - `bin.id` could be silently swapped with a `RecipeId` or any other string at the call site. - Mapper synthetic sink IDs (`disposal-<recipeId>`, `<binId>-bldg<idx>`, target-sink IDs) were structurally indistinguishable from real `Bin.id` values in the producer/consumer maps. - `useProductionTable.ts`' `primaryRecipeId = bin.recipeIds[0]` convention relied on the packer's sort contract via plain string-equality. # Approach Add `BinId = string & { readonly __brand: "BinId" }` to `src/types/constants.ts` (and re-export through `@/types`). Brand intersection (not literal-string union) is the right pattern here because the value set is not enumerable. Thread `BinId` through: - `Bin.id` (the canonical home). - `RecipeBinAllocation.perBin[].binId`. - `ProductionNode.binId`, `ProductionGraphNode.binId` (the recipe-variant in production graph). - `ProductionLineData.binId`, `ProductionLineData.binSpanningInfo[].binId` (table rows). - `buildBinActivitySums`' return type: `Map<BinId, number>`. - `resolveBinInfo`' return type in calculator.ts: `binId: BinId | undefined`. - `bin-fused-mapper.ts`'s `singletonTerminalBinIds: Set<BinId>`, `binsById: Map<BinId, Bin>`, `buildingInstanceId(binId: BinId, idx)`, `binIdFromInstanceId`' return type. - `makeBinId` in `multi-formula-packing.ts`: returns `BinId` via a single `as BinId` cast at the construction site (the only permissible cast — every other site receives `BinId` by inheritance through the type system). # Boundaries kept as plain `string` - Mapper `ProducerEntry` / `ConsumerEntry` `binId: string` is a union — either a real `BinId` or a synthetic sink ID. Comment clarifies the mixed semantics. - Per-building instance IDs (`<binId>-bldg<idx>`) are derived syntactic IDs; stay `string`. - Target sink IDs (`createTargetSinkId(...)`) and disposal sink IDs (`disposal-<recipeId>`) stay `string`. # Test fixtures `bin-fusion-mapper.test.ts` (5 sites) and `plan-helpers.test.ts` (5 sites) now use `"bin-..." as BinId` for synthetic fixture construction — analogous to existing `as ItemId` / `as RecipeId` casts elsewhere in the same test files. One additional `n.id as BinId` cast at the test boundary where React Flow node IDs (plain string) are compared against a `Set<BinId>` built from real bin IDs. # Pre-ship - `bunx tsc -b --noEmit` clean (type-check-only). - 206/206 tests pass. - 4 sensitive suites individually pass: calculator (92), flow- integrity (10), bin-fusion-mapper (30), multi-formula-packing (24). - Lint clean of new warnings (3 pre-existing shadcn warnings remain). - Build succeeds. * Refactor: Tighten as-never test fixture casts to as-unknown-as branded types Test-only churn; no runtime effect. `bin-fusion-mapper.test.ts` previously used `as never` / `as never[]` to cast synthetic fixture IDs into the closed-enum literal-union types (`FacilityId`, `RecipeId`). `never` is the bottom type and assignable to anything, so it worked, but it obscures both the intent ("this is a synthetic test ID") and the target type. Replace with the conventional double-cast pattern `as unknown as TypeName`, matching the existing precedent at line 82 (`new Set(["a"] as unknown as ItemIdType[])`). Changes: - Add `FacilityId as FacilityIdType` import alongside existing `ItemIdType` / `RecipeIdType` aliases. - 6 `facilityId: "fac" as never` -> `as unknown as FacilityIdType` (mkRecipe helper + 5 Bin fixtures). - 5 `recipeIds: [...] as never[]` -> `as unknown as RecipeIdType[]` (5 Bin fixtures with synthetic recipe IDs `ra`/`rb`/`rs`/`rl` or empty). - 2 `b.facilityId === ("liquid_purifier_1" as never)` -> `as FacilityIdType` (single-cast — `liquid_purifier_1` is a real FacilityId enum value, no double-cast needed). Pre-ship: TS clean, 206/206 tests pass, lint clean of new warnings, build succeeds.
Summary
Replaces the SCC flow solver's heuristic stack — Gaussian elimination + ad-hoc row dropping +
max(b/a)tie-breaking — with a linear-program formulation backed byjavascript-lp-solver(~30 KB Unlicense). Two follow-up fixes are bundled: phantom-disposal-sink suppression for floating-point residuals, and Phase 4.5 raw-material deficit propagation for raw items inside an SCC.Evidence — three-way comparison
30 scenarios run against
v0.6.2(pre-PR #73, commit 339fb5b),masterat PR #73's merge commit (49be16e), and this PR's HEAD.PR #73 fixed the Xiranite Jade Gourd over-consumption (#72) but introduced 4 new mass-balance failures on the SC Wuling Battery / Heavy Xiranite chain. v0.6.2 ran them correctly; master under-allocates the Refining Unit, leaving the Xircon refinement chain with a Sewage deficit. LP recovers v0.6.2's metrics exactly, additionally fixes 1 pre-existing bug (Heavy Xiranite + Cuprium Part), and preserves PR #73's intended Gourd fix.
Master's raw count on the regression rows is computed against an invalid plan that under-runs the Refining Unit and under-reports the Cuprium Ore and Clean Water it would actually need. LP matches v0.6.2 — both are the true cost of a valid factory; master's "lower" number is fiction.
Motivation
PR #73's
filterImpossibleDisposalRowsrow-priority heuristic was correct for the Xiranite Jade Gourd case and incorrect for the SC Wuling Battery case. The heuristic stack — Gaussian elimination with priority hints — can't satisfy both simultaneously without a richer formulation. LP solves all five failures structurally via explicit constraints + slack variables.LP formulation
Variables
x[r] ≥ 0per SCC recipe. Per-item constraints select operator from{equal, min, disposal-slack}based on item type; raw materials are excluded from constraints (infinite-supply, counted only in cost); recipe overrides become equality pins during feeder extension. Lex two-pass objective: minimize raw consumption, then minimize power subject to raw-cost at pass-1 optimum + 1e-6. Full constraint catalogue inlp-solver.tsJSDoc.Bundled fixes
Phase 4.5 raw deficit propagation (
57bbfd2): raw items inscc.items(placed by Tarjan via byproduct cycles) were dropped fromitemDemandsbecause Phase 5 only iteratesscc.externalInputs. NewpropagateRawMaterialDeficitmirrors master's heuristic Phase 4. Affects Clean Water (viaLIQUID_PURIFIER_XIRANITE_POLY_1) and Precipitation Acid (viaLIQUID_PURIFIER_COPPER_ENR_1).Phantom disposal sink suppression (
19c301d): non-integer LP facility counts produced ~1e-13 residuals that triggered phantom disposal-recipe injection — visible as disconnected "0/min" sinks at certain target rates. NewSURPLUS_EPSILON = 1e-6tolerance + defensive mapper filters.Scope
11 files, ~+1365 / −696 LOC. Adds
src/lib/lp-solver.ts(+451), rewritessrc/lib/flow-solver.ts, deletessrc/lib/linear-solver.tsand its tests, adds 12 unit tests for the LP module, adds 8 regression tests for the bundled fixes. Addsjavascript-lp-solver^1.0.3 (~30 KB MIT).Tests
99/99 pass.